公司内部组件库设计思路

您所在的位置:网站首页 vue element组件库 公司内部组件库设计思路

公司内部组件库设计思路

#公司内部组件库设计思路| 来源: 网络整理| 查看: 265

前提基础:vite + pnpm(workspace) + vue3 + ts + scss + element-plus

组件库整体设计分为三块:

svg 图标库 - icons vue 组件库 - ui 图标及组件的测试项目 - website

整体设计采用 pnpm 的 workspace 方式来管理三个项目:

packages - icons - ui - website package.json pnpm-workspace.yaml # pnpm-workspace.yaml packages: - 'packages/**' 图标库

在公司项目中,我们一般都需要自定义一些图标,为了更好的管理和使用,所以应该要有一个内部的图标库。

目前 UI 组件库的图标管理一般分为两种(瞎扯的):

第一种是把图标转成字体,然后在项目内使用 className 的方式进行使用 第二种就是像 element-plus 一样,直接将 svg 图标封装成一个个 vue 组件,然后在项目内当成 vue 组件来使用

至于有没有其它的方式,我也太多的去了解哈。

因为我们的组件库是以 element-plus 为基础的,所以采用第二种进行封装。

以组件的方式封装图标,比较简单,大体就是下面这样:

export default { name: 'IconName' }

所以我们只需要将 UI 设计好的 svg 拿到手,按照上面简单的封装下就可以用了。

但是这里要注意一点的是,使用 svg 作为图标是因为 svg 的颜色渲染可以选择性的继承父级元素的颜色。所以我们需要将需要继承父元素颜色的 path 的 fill 的值设为 currentColor,而 UI 设计出的原始 svg 文件好像是做不到这点,所以我们拿到之后就需要手动调整下。

为了减少人为的工作和低级错误,我们可以对 svg 转 vue 组件这一步写一个脚本,自动去转换,思路:

读取 svg 路径目录下的所有 svg 文件 用写好的模板直接生成 vue 组件 // scripts/generate-components.js const fs = require('fs') const path = require('path') const { success, error } = require('./logger') const iconsPath = path.resolve(__dirname, '../') const svgsPath = path.resolve(iconsPath, './svgs') const componentsPath = path.resolve(iconsPath, './packages') // 读取 svg 文件内容 function getSvg(path) { return fs.readFileSync(path).toString() } // 获取 vue 的组件名 function getVueComponentName(name) { return name.replace(/(^\w)|(-\w)/g, a => (a[1] || a[0]).toUpperCase()) } // 生成 vue 组件 function generateVueContent(svgContent, fileName) { const vueName = fileName.split('.')[0] let context = `\n${svgContent}\n\n\n` context += `\ export default { name: '${getVueComponentName(vueName)}' } ` return context } // 生成 vue 文件 function createComponentFile(content, fileName) { if (!fs.existsSync(componentsPath)) { fs.mkdirSync(componentsPath) } const vueFilePath = path.resolve(componentsPath, fileName + '.vue') fs.writeFile(vueFilePath, content, (err) => { if (err) { error(err) return } success(`文件 ${vueFilePath} 创建成功!`) }) } // 生成 vue 组件汇总文件 function createIndex(fileNames) { let content = "export * from '@element-plus/icons-vue'\n\n" const indexPath = path.resolve(componentsPath, './index.ts') const components = [] fileNames.forEach(fileName => { const name = fileName.split('.')[0] const vueComponentName = getVueComponentName(name) components.push(vueComponentName) content += `import ${vueComponentName} from './${name}.vue'\n` }) content += `\ \nexport { ${components.join(',\n\t')} } ` fs.writeFile(indexPath, content, (err) => { if (err) { error(err) return } success(`文件 ${indexPath} 创建成功!`) }) } // 初始化 function init() { // 先清除components内所有文件 if (fs.existsSync(componentsPath)) { fs.rmSync(componentsPath, { recursive: true }) } const svgs = fs.readdirSync(svgsPath) svgs.forEach(svgName => { const svgPath = path.resolve(svgsPath, svgName) const ctx = getSvg(svgPath) const fileName = svgName.split('.')[0] // 生成vue组件内容 const vueContent = generateVueContent(ctx, fileName) // 生成vue组件文件 createComponentFile(vueContent, fileName) }) // 创建汇总index.ts createIndex(svgs) } init()

然后在 package.json 文件中添加脚本执行命令:

{ // ... "scripts": { "build": "node scripts/build.js", "prebuild": "node scripts/generate-components.js" }, // ... }

如果我们需要与 @element-plus/vue-icons 一起用话,我门需要在组件汇总文件处引入 element-plus 的图标库。

// 引入并抛出 element-plus 的图标组件 export * from '@element-plus/icons-vue' import IcAvatar from './ic-avatar.vue' import IcBbgl from './ic-bbgl.vue' // 其它图标 export { IcAvatar, IcBbgl, // ... }

最后进行打包并发布。

// scripts/build.js const path = require('path') const { defineConfig, build } = require('vite') const vue = require('@vitejs/plugin-vue') const dts = require('vite-plugin-dts') const entryDir = path.resolve(__dirname, '../packages') const outputDir = path.resolve(__dirname, '../dist') build(defineConfig({ configFile: false, publicDir: false, plugins: [ vue(), dts({ include: './packages', outputDir: './types' }) ], build: { rollupOptions: { external: [ 'vue', '@element-plus/icons-vue' ], output: { globals: { vue: 'Vue' } } }, lib: { entry: path.resolve(entryDir, 'index.ts'), name: 'xxxIcons', fileName: 'xxx-icons', formats: ['es', 'umd'] }, outDir: outputDir } }))

使用的方式就是和 @element-plus/vue-icons 一样了。

组件库

关于组件怎么写就不讲了,这里主要介绍组件库的构建设计。

为了更方便,更规范的去管理所有组件及方法,所以我们可以对组件的格式进行简单的约定。

ui - packages // 组件目录 - component-name // 组件 - __tests__ // 组件单元测试 - src // 组件内容 - index.ts // 组件入口文件 - scripts // 构建脚本 - src // 组件库通用配置 - index.ts // 组件库的打包入口文件 - package.json - tsconfig.json // 其它文件

组件入口文件格式:

import type { App } from 'vue' import ComponentName from './src/index.vue' import './src/index.scss' export { ComponentName } export default { install: (app: App) => { app.component(ComponentName.name, ComponentName) } }

组件库的打包入口文件格式:

import type { App } from 'vue' import componentNameInstall, { componentName } from '../packages/ep-announcement-list' // 其它组件引入... import { version } from '../package.json' // 组件库的全局配置方法 import { setupGlobalOptions } from './global-config' const components: Array void }> = [ componentNameInstall, // ... ] const install = (app: App, opts = {}) => { app.use(setupGlobalOptions(opts)) components.forEach(component => { app.use(component) }) } const ui = { version, install } export { componentName, // 其它组件 install } export default ui

因为单个组件的入口文件和打包的入口文件的格式已经固定,为了减少人为的工作和一些低级错误的出现,这里我们可以使用 @babel/traverse 和 @babel/parser 对 packages 下面的文件进行简单的 AST 词法分析,解析出默认导出和单个导出变量,然后进行组装 scripts/index.ts 文件:

const fs = require('fs') const path = require('path') const traverse = require("@babel/traverse").default const babelParser = require("@babel/parser") const { success, error } = require('./logger') const ignoreComponents = [] // 将 kebab-case 转换为 PascalCase 格式 function getComponentName(name) { return name.replace(/(^\w)|(-\w)/g, a => (a[1] || a[0]).toUpperCase()) } // 进行 AST 词法分析并获取内部抛出变量 function analyzeCode(code, filePath, dir, components, services) { const ast = babelParser.parse(code, { sourceType: 'module', plugins: ['typescript'] }) const exportName = [] let exportDefault = '' traverse(ast, { // 单个导出 ExportNamedDeclaration({ node }) { if (node.specifiers.length) { node.specifiers.forEach(specifier => { exportName.push(specifier.local.name) }) } else if (node.declaration) { if (node.declaration.declarations) { node.declaration.declarations.forEach(dec => { exportName.push(dec.id.name) }) } else if (node.declaration.id) { exportName.push(node.declaration.id.name) } } }, // 默认导出 ExportDefaultDeclaration({ node }) { exportDefault = getComponentName(dir) + 'Install' components.push(exportDefault) } }) if (!exportDefault && !exportName.length) { return '' } let exp = 'import ' if (exportDefault) { exp += `${exportDefault}` } if (exportName.length) { services.push(...exportName) if (exportDefault) { exp += ', ' } exp += `{ ${exportName.join(', ')} }` } exp += ` from '${path.join(filePath, dir)}'`.replace(/\\/g, '/') return exp } const relativePath = '../packages' const desPath = path.resolve(__dirname, '../src/index.ts') const packagePath = path.resolve(__dirname, relativePath) let exportExp = "import type { App } from 'vue'\n" const components = [] const services = [] // 组装 index.ts 文件内容 function getCode() { fs.readdirSync(packagePath).forEach(dir => { if (ignoreComponents.includes(dir)) { return } const dirPath = path.resolve(packagePath, dir) if (fs.statSync(dirPath).isDirectory()) { const filePath = path.resolve(dirPath, 'index.ts') const code = fs.readFileSync(filePath, 'utf-8') const exp = analyzeCode(code, relativePath, dir, components, services) if (exp) { exportExp += exp + '\n' } } }) exportExp += ` import { version } from '../package.json' import { setupGlobalOptions } from './global-config' const components: Array void }> = [ ${components.join(',\n ')} ] const install = (app: App, opts = {}) => { app.use(setupGlobalOptions(opts)) components.forEach(component => { app.use(component) }) } const xxxxui = { version, install } export { ${services.join(',\n ')}, install } export default xxxxui ` return exportExp } // 保存为文件 function save(desPath, code) { fs.writeFile(desPath, code, err => { if (err) { error(err) throw err } success(`文件 ${desPath} 创建成功!`) }) } // 构建 function build() { save(desPath, getCode()) } build()

在入口文件生成之后,然后进行组件库的打包构建。

组件库的打包分为整体打包和单个组件打包,单个组件的打包是为了考虑组件库的按需加载功能。

const path = require('path') const fs = require('fs') const fsExtra = require('fs-extra') const { defineConfig, build } = require('vite') const vue = require('@vitejs/plugin-vue') const dts = require('vite-plugin-dts') const pkg = require('../package.json') const entryDir = path.resolve(__dirname, '../packages'); const outputDir = path.resolve(__dirname, '../dist'); const baseConfig = defineConfig({ configFile: false, publicDir: false, plugins: [ vue(), dts({ include: ['./packages', './src'], outputDir: './types' }) ] }) const createBanner = () => { return `/*! * ${pkg.name} v${pkg.version} * (c) ${new Date().getFullYear()} UI * @license ISC */` } const rollupOptions = { external: [ 'vue', // 组件库不打包内部引用的第三方包 ], output: { globals: { vue: 'Vue' }, banner: createBanner() } } const getFilename = (filename, format) => { // format => es | umd return `${filename}.${format}.js` } // 单个组件打包 const buildSingle = async (name) => { await build( defineConfig({ ...baseConfig, build: { rollupOptions, lib: { entry: path.resolve(entryDir, name), name: 'index', fileName: getFilename.bind(null, 'index'), formats: ['es', 'umd'] }, outDir: path.resolve(outputDir, name) } }) ) } // 整体打包 const buildAll = async () => { await build( defineConfig({ ...baseConfig, build: { rollupOptions, lib: { entry: path.resolve(__dirname, '../src/index.ts'), name: 'Xxxxui', fileName: getFilename.bind(null, 'xxxx-ui'), formats: ['es', 'umd'] }, outDir: outputDir } }) ) } // 单个组件打包的 package.json 文件 const createPackageJson = (name) => { const fileStr = `{ "name": "${name}", "version": "0.0.0", "main": "index.umd.js", "module": "index.es.js", "style": "style.css", "types": "../../types/packages/${name}/index.d.ts" }` fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8'); } // 这一步是是因为组件库的某些组件是没有 css 的 // 为了按需加载功能的样式引入,需要创建一个空白的 css 文件 const createBlankCssFile = (name) => { const cssFilePath = path.resolve(outputDir, `${name}/style.css`) if (!fs.existsSync(cssFilePath)) { fsExtra.outputFile(cssFilePath, '', 'utf-8'); } } const buildUI = async () => { await buildAll() const components = fs.readdirSync(entryDir).filter((name) => { const componentDir = path.resolve(entryDir, name); const isDir = fs.lstatSync(componentDir).isDirectory(); return isDir && fs.readdirSync(componentDir).includes('index.ts'); }); for (const name of components) { await buildSingle(name); createPackageJson(name); createBlankCssFile(name) } } buildUI()

最后在 package.json 中的 scripts 添加两个命令:

{ // ... "scripts": { // 执行打包 "build": "node scripts/build.js", // 生成打包入口文件 "prebuild": "node scripts/generate-index.js" }, // ... }

上面组件的打包只用了 es 和 umd 格式,如果需要其它的加上就行。这上面其实有一个问题,在 getFilename 方法中定义不同格式的文件名,如果不用函数定义,es 格式打包出来的是 .mjs 文件后缀,但是发现这样在使用的时候,如 monaco-editor 等有些组件会报找不到引用地址的错误,但是定义成 .js 后缀就是 OK 的,我也没搞明白 -_- !

到这里打包发布之后我们在项目中就可以使用了,下面是按需加载的用法:

// vue.config.js // ^0.21.1,之前用的是 0.17.x,0.21.x 版本返回值字段有变更,具体哪个版本变更的没去细看... // 注意:这里用的是webpack,vite 的用 vite 版本 const Components = require('unplugin-vue-components/webpack') module.exports = { // 其它配置... configureWebpack: { // 其它配置... plugins: [ // 按需加载 Components({ resolvers: [ name => { // 所有组件库内的组件名规定一个前缀,用来判断是自己的组件 if (name.startsWith('Yy')) { // 将 PascalCase 转换为 kebab-case 格式 const rawFileName = name.replace( /[A-Z]/g, (a, b) => (b ? '-' : '') + a.toLowerCase() ) return { sideEffects: [`xxxx-ui/dist/${rawFileName}/style.css`], name, from: 'xxxx-ui' } } } ] }), // 其它插件... ] } }

到这里 vue 的组件库已经完成了。

图标/组件测试项目

因为是公司项目的内部组件,在组件测试的时候有可能需要和项目一样的环境,然后我们的项目是用的 qiankunjs 做的微服务设计。所以这里我是放了一个子应用,挂在基座项目下,这样我们的测试项目也就拥有了和正式项目上一模一样的环境。

下面就是怎么去做组件库测试和联调了。

因为 pnpm 提供的一些功能,同级的包在 package.json 内用 workspace:^1.0.0 的方式直接调用,当组件库或者图标做的修改,website 是可以直接进行热更新的。而我们需要测试两种方式,一种是开发调试,一种是打包后的调试,这样我们可以添加两个 scripts 命令:

{ // ... "scripts": { // 打包调试 "dev": "vue-cli-service serve", // 开发调试 "devtc": "vue-cli-service serve -t test", } }

然后修改 vue.config.js:

const Components = require('unplugin-vue-components/webpack') const type = process.argv[4] function resolveComponent() { return name => { if (name.startsWith('Yy')) { // 开发调试 if (type === 'test') { return { name: name, from: 'xxxx-ui/src' } } // 打包后调试 const rawFileName = name.replace( /[A-Z]/g, (a, b) => (b ? '-' : '') + a.toLowerCase() ) return { sideEffects: [`xxxx-ui/dist/${rawFileName}/style.css`], name: name, from: 'xxxx-ui' } } } } module.exports = { // 其它配置... configureWebpack: { plugins: [ Components({ resolvers: [ resolveComponent() ] }), // ... ] } }

这里就可以启动项目进行调试了。

git hooks

最后我们可以对组件库添加一些代码规范、文件命名规范及提交规范等。

下面的工具只进行简单的使用,如有更好的用法,欢迎探讨

husky

husky 可以让我们更好的去向项目中添加 git hooks:

pnpm add husky -wD

然后在 package.json 中的 scripts 添加:

{ "scripts": { "prepare": "husky install" } }

安装之后在根目录会有一个 .husky 文件夹。

添加 git hook:

npx husky add .husky/pre-commit "npm run test"

在 .husky 下面会生成一个 .pre-commit 文件

#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run test

这里就是在每次提交 commit 之前会执行 npm run test。

commitlint

运行下面命令,可以生成 .husky/.commit-msg 文件:

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

.husky/.commit-msg 文件。

#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx --no-install commitlint --edit $1

添加 @commitlint/cli @commitlint/config-conventional 两个组件。

设置 commit-msg 规则:

添加 commitlint.config.js 文件: // commitlint.config.js const types = [ 'feat', // 新增功能 'fix', // bug 修复 'docs', // 文档更新 'style', // 代码修改 'refactor', // 重构代码 'perf', // 性能优化 'test', // 新增或更新现有测试 'build', // 修改项目构建系统 'chore', // 其它类型,日期事务 'revert' // 回滚之前提交 ]; module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-empty': [2, 'never'], 'type-enum': [2, 'always', types], 'scope-case': [0, 'always'], 'subject-empty': [2, 'never'], 'subject-case': [0, 'never'], 'header-max-length': [2, 'always', 88], }, };

这样我们每次 commit 的提交信息的规则是 [types]: 提交信息,如不符合则会报错,中止提交。

eslint

这里添加 js 代码检测规范:

安装 eslint:

pnpm add eslint -wD

添加 scripts 命令:

// xxxx-* 是图标库与组件库的目录名匹配 { "scripts": { "eslint": "eslint \"packages/xxxx-*/**/{*.ts,*.vue}\"", "eslint:fix": "eslint --fix \"packages/xxxx-*/**/{*.ts,*.vue}\"", } }

然后添加 eslint 配置文件 .eslintrc.js:

// .eslintrc.js 这里根据项目自己配置 module.exports = { "root": true, "env": { "node": true }, "extends": [ "plugin:vue/vue3-essential", "eslint:recommended", "@vue/typescript" ], "parserOptions": { "parser": "@typescript-eslint/parser" }, "globals": { "defineProps": "readonly", "defineEmits": "readonly", "defineExpose": "readonly", "withDefaults": "readonly" }, "rules": { "no-unused-vars": ["warn", { "varsIgnorePattern": "[iI]gnored", "argsIgnorePattern": "^_" }], // ... } } stylelint

这里添加 css 检测规范:

添加 stylelint stylelint-config-standard-scss stylelint-config-recommended-scss stylelint-scss 组件。

添加 scripts 命令:

// 这里只检测 ui 库的样式 { "scripts": { "stylelint": "stylelint \"packages/xxxx-ui/**/*.scss\"", "stylelint:fix": "stylelint --fix \"packages/xxxx-ui/**/*.scss\"" } }

然后添加 .stylelintrc.json 文件:

// .stylelintrc.json { "extends": [ "stylelint-config-standard-scss", "stylelint-config-recommended-scss" ], "plugins": [ "stylelint-scss" ], "rules": { // 规范自己根据项目选择 "color-no-invalid-hex": true, "font-family-no-duplicate-names": true, "function-calc-no-unspaced-operator": true, "selector-class-pattern": "^[a-z_]+(-{1,2}[a-z_]+)*$", "selector-pseudo-class-no-unknown": [ true, { "ignorePseudoClasses": [ "deep" ] } ] } } lint-staged

lint-staged 是一个文件过滤和命令重组的工具,用法如下:

// package.json { "lint-staged": { "packages/xxxx-*/**/*.{ts,vue}": "eslint --fix", "packages/xxxx-ui/**/*.scss": "stylelint --fix" } }

这里就是对命令的一个重新配置,可以理解为:

eslint --fix "packages/xxxx-*/**/*.{ts,vue}" stylelint --fix "packages/xxxx-ui/**/*.scss" @ls-lint/ls-lint

@ls-lint/ls-lint 可以用来检测不同文件类型的文件名的命名规则:

pnpm add @ls-lint/ls-lint -wD

在根目录添加 .ls-lint.yml 文件:

# .ls-lint.yml ls: packages: .dir: kebab-case | regex:__[a-z0-9]+__ .scss: kebab-case .vue: kebab-case | PascalCase .js: kebab-case .svg: kebab-case .ts: kebab-case .d.ts: kebab-case ignore: # xxxx-icons - packages/xxxx-icons/dist - packages/xxxx-icons/node_modules - packages/xxxx-icons/types - packages/xxxx-icons/src # xxxx-ui - packages/xxxx-ui/assets - packages/xxxx-ui/dist - packages/xxxx-ui/node_modules - packages/xxxx-ui/types # website - packages/website

最后修改 .husky/pre-commit 文件:

#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" - npm run test + pnpx @ls-lint/ls-lint && pnpx lint-staged

这里在每次提交之前,都会执行 pnpx @ls-lint/ls-lint && pnpx lint-staged 命令,对修改的文件进行文件名、js代码、css代码检测。

到这里,所有配置都已完成了。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3